Skip to content

feat(sponsors): embeddable /sponsors.svg + /sponsors.png image router for READMEs#483

Merged
Brooooooklyn merged 31 commits into
mainfrom
feat/sponsors-image-router
Jul 6, 2026
Merged

feat(sponsors): embeddable /sponsors.svg + /sponsors.png image router for READMEs#483
Brooooooklyn merged 31 commits into
mainfrom
feat/sponsors-image-router

Conversation

@Brooooooklyn

Copy link
Copy Markdown
Member

What

Adds a runtime image router that renders the napi-rs sponsor wall as an embeddable image, so sponsors can be displayed in external READMEs (GitHub, npm, and the napi crate):

Route Content-Type Best for
GET /sponsors.svg image/svg+xml GitHub README (crisp; avatars base64-inlined so it survives Camo)
GET /sponsors.png image/png npm & crates.io (where SVG is unreliable)

Query param ?theme=light\|dark (default light) so the image reads well on either README theme.

How it works

GET /sponsors.{svg,png}?theme=
   └─ loadSponsors()                 reuses the existing live GitHub Sponsors loader (10-min cached, never throws)
   └─ inline avatars                 fetch each (3s timeout) → base64 data URI (Camo-safe; PNG/JPEG only; failures dropped)
   └─ satori (satori/standalone)     React-less nodes → SVG (text vectorized to <path>, avatars as <image>)
        ├─ /sponsors.svg → return SVG
        └─ /sponsors.png → @resvg/resvg-wasm rasterizes at 2× → PNG

Both wasm modules (satori/yoga.wasm, @resvg/resvg-wasm/index_bg.wasm) are statically imported by the route files — Void's Cloudflare build turns them into real WebAssembly.Module objects at the edge (verified in the workerd preview). Fonts (Manrope 700/500) are vendored to public/fonts/ and read at runtime via the ASSETS binding. Heavy logic lives in small, unit-tested lib/sponsors-image/* units; the route files are thin adapters.

Only new dependency: @resvg/resvg-wasm@2.6.2 (the existing build-time @resvg/resvg-js is untouched).

Embedding in a README

<!-- npm / crates.io -->
[![napi-rs sponsors](https://napi.rs/sponsors.png)](https://napi.rs/#sponsor)
<!-- GitHub (auto light/dark) -->
<a href="https://napi.rs/#sponsor">
  <picture>
    <source media="(prefers-color-scheme: dark)"  srcset="https://napi.rs/sponsors.svg?theme=dark">
    <img src="https://napi.rs/sponsors.svg?theme=light" alt="napi-rs sponsors">
  </picture>
</a>

Testing

  • 18 new unit tests across theme / avatars / fonts / card / resvg / render / yoga-init; full suite: 28 files, 248 passing (no regressions to the 228 baseline).
  • Smoke-tested in the real workerd runtime: both routes return 200 with the correct Content-Type, Cache-Control: public, s-maxage=1800, max-age=600, stale-while-revalidate=86400, valid PNG magic bytes, and 17 live sponsors rendered.
  • Rendered output visually verified (tiered circular avatars: Special Thanks → Gold → Silver → Backers, sized largest→smallest; empty tiers skipped; backers capped at 100 before fetching).

Notes

  • Caching: relies on the edge s-maxage + the existing 10-min in-isolate sponsor cache; avatar fan-out only happens on a cache miss.
  • Out of scope: embedding the snippets into the external napi-rs / crate / npm READMEs (done in those repos), rendered sponsor names, per-sponsor clickable regions (stripped by Camo anyway).

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 097b993c02

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/sponsors-image/avatars.ts Outdated
Brooooooklyn and others added 15 commits July 6, 2026 11:41
…on limit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /sponsors.png and /sponsors.svg routes now serve the cron/webhook
-warmed blob straight from R2 via the KV manifest (readImage). On a cold
cache miss they render THIS request live and kick off a background
refreshSponsorsCache(force) that warms all 4 blobs (svg/png x light/dark)
for next time. Cache-Control widened to
`public, s-maxage=600, max-age=600, stale-while-revalidate=86400`.

The three landing loaders (en/cn/pt-BR) read getCachedSponsors(KV) — the
cron/webhook-maintained snapshot when present, else the live GitHub fetch
so the wall still works before the first refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…KV parse + live cold render

- refresh: guard against clobbering a good snapshot with an empty (degraded)
  fetch; read manifest once and reuse for the version check. Cold cache still
  writes empty (nothing better to serve).
- store: writeSnapshot re-reads the manifest and only deletes the previous
  version's R2 blobs if we are still the published version (concurrent-supersede
  guard). readManifest/readData parse defensively -> null on corrupt JSON so
  callers fall back instead of 500ing.
- routes/sponsors.{png,svg}: cold miss renders from the live loader (spec), not
  KV-first getCachedSponsors; drop the now-unused import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
crypto.subtle.verify's BufferSource param rejects the widened
Uint8Array<ArrayBufferLike> under TS 5.7+; the fresh allocation is
already backed by a plain ArrayBuffer, so annotate it. Type-only, no
runtime change. Clears the last non-pre-existing tsc --noEmit error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The image cold path already loads good sponsor data (loadSponsors()) and
renders the response from it. The background warm re-fetched via
loadSponsors({bypassCache:true}); if that 2nd fetch degraded to empty
while the 1st succeeded, refreshSponsorsCache — on a cold cache with no
manifest, so the isEmpty guard can't trip — published an empty first
snapshot over a request that had live data. Reuse the already-loaded
`sponsors` for the warm (also drops a redundant GitHub fetch per cold
miss). cron/webhook keep the live bypassed fetch (no preloaded payload).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…OCTOU)

The isEmpty guard only skipped when a manifest already existed (`&& current`),
and current was read ONCE before the slow render. On a cold cache an empty
degraded refresh saw no manifest, rendered, and by writeSnapshot time a
concurrent good refresh may have published — the empty writer then overwrote
it (and its delete-guard removed the good version's R2 blobs). Drop the
`&& current`: refuse to publish an all-empty (degraded) snapshot at all.
A cold cache stays cold so cold-misses live-render and self-heal when GitHub
recovers; the empty writer never reaches writeSnapshot, so it can't clobber a
concurrently-published good snapshot. (Healthy fetch always has Special Thanks,
so all-empty reliably means degraded.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ead569f4d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/sponsors-cache/refresh.ts Outdated
…ollback

Two non-empty refreshes with different versions can overlap: the older reads
the manifest at start, spends seconds rendering, and its writeSnapshot then
wrote stale data+manifest back (and the delete-guard, seeing itself as current,
deleted the newer version's R2 blobs). writeSnapshot now takes the version
observed at refresh start and re-reads the pointer right before flipping:
if a different version was published mid-render it aborts, dropping the blobs
it just uploaded, so a slower/staler writer can't roll the cache back. Deletes
now target the version we actually CAS'd against. Addresses Codex PR review P2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5fce43e967

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/landing/get-sponsors.ts
Comment thread lib/sponsors-cache/store.ts Outdated
…read errors

Addresses two Codex PR review P2s:
- store: writeSnapshot staged every render under a per-render id
  (sponsors/<version>/<renderId>/…) so a forced same-version re-render (daily
  avatar refresh) no longer overwrites the keys the live manifest serves. A
  partial r2.put failure or an aborted CAS can't corrupt or roll back the live
  cache — readers only ever follow the manifest to a complete folder, published
  by the single atomic flip. Cleanup now deletes the previous manifest's blobs
  by key (so a same-version render still frees the old folder).
- store: readManifest/readData now wrap the whole read (not just JSON.parse) so
  a rejected kv.get (transient KV error) degrades to a miss (null) instead of
  500ing the landing loader via getCachedSponsors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: caf3053f89

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/sponsors-cache/store.ts Outdated
Comment thread lib/sponsors-cache/store.ts
Brooooooklyn and others added 2 commits July 6, 2026 15:11
readImage wrapped only the manifest read; a rejected r2.get / arrayBuffer
(transient R2 error) escaped and 500'd /sponsors.{svg,png} before their cold
live-render fallback. Treat an R2 read failure as a cache miss (null) too, so a
broken warm cache degrades to a live render. Addresses Codex PR review P2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
KV has no atomic compare-and-swap, so a ~ms read→put window in the manifest
publish can't be closed at the KV layer. Document why it's acceptable here
(overlap needs two refreshes within ms with changed data; self-heals next
refresh) and that a Durable Object / R2 ETag CAS would be disproportionate for
a daily-refreshed sponsor wall. Response to Codex PR review P2 (accepted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a366f8676d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread routes/sponsors.svg.ts Outdated
…ot bare live

On a cold image miss the routes fetched live (loadSponsors) directly; a GitHub
timeout/non-200/missing-token then rendered an empty wall that got served AND
CDN-cached for s-maxage — even when a good sponsor snapshot sat in KV. Render
from getCachedSponsors(KV) instead: last-good KV snapshot first, live only when
KV has nothing; the background force-refresh still warms fresh blobs. Supersedes
the earlier "cold miss -> live render" (spec doc updated). Addresses Codex PR
review P2 (reverses the round-1 spec-compliance note on the same line).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Brooooooklyn Brooooooklyn merged commit 7f3b1f7 into main Jul 6, 2026
2 checks passed
@Brooooooklyn Brooooooklyn deleted the feat/sponsors-image-router branch July 6, 2026 07:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: abc0615620

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread routes/sponsors.svg.ts
)
}
return new Response(body as BodyInit, {
headers: { 'Content-Type': contentType, 'Cache-Control': CACHE_CONTROL },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid caching degraded cold sponsor renders

When the image cache is cold (or a transient KV read makes getCachedSponsors() miss) and the live loader degrades to its all-empty sponsor list, this still returns the normal public 10-minute cache header. That lets GitHub/Camo or the edge cache an empty sponsor wall even though the refresh pipeline deliberately refuses to publish empty snapshots; use a no-store/very short TTL or an error response for all-empty cold renders. The PNG route has the same unconditional cold-response cache header.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant